Skip to content

feat: focus visually first invalid field across composed forms#1682

Merged
serikjensen merged 4 commits into
mainfrom
feat/cross-form-dom-order-focus
May 7, 2026
Merged

feat: focus visually first invalid field across composed forms#1682
serikjensen merged 4 commits into
mainfrom
feat/cross-form-dom-order-focus

Conversation

@serikjensen
Copy link
Copy Markdown
Member

Summary

When composeSubmitHandler validates multiple forms and any are invalid, focus now lands on the field that is visually first in the DOM rather than the first form's first error in array order. This fixes the cross-form gap that surfaces when partners interleave fields from multiple form hooks on the same page.

How it works

A per-form FieldElementRegistry is populated by useField on mount/unmount (via a ref callback added to its existing useForkRef) and read by composeSubmitHandler to sort invalid fields by getBoundingClientRect() and focus the winner directly.

Direct element.focus() bypasses RHF's setFocus, which doesn't reliably resolve refs through react-aria-components (_fields[name]._f.ref ends up as a synthetic placeholder rather than the DOM node).

Two registry-publish paths

The registry is published via context through two complementary paths covering both field-connection modes documented in docs/hooks/hooks.md:

  1. Provider-basedSDKFormProvider wraps with FieldElementRegistryProvider.
  2. Prop-based — each HookField self-wraps via withFieldElementRegistry using the registry forwarded on the formHookResult prop, so the focus behavior works even when SDKFormProvider is absent.

Errors captured from onInvalid

Reads errors from each form's handleSubmit(onValid, onInvalid) callback rather than formState.errors — the latter is a proxy that returns {} when accessed outside a component subscription.

Hooks aligned

All eight SDK form hooks now construct hookFormInternals via the new useHookFormInternals(formMethods) helper, which attaches the registry instance:

Wrapper hooks (useCurrentHomeAddressForm, useCurrentWorkAddressForm) need no changes — they spread the underlying hook's hookFormInternals.

Compatibility with raw UseFormReturn slots

The ComposeSubmitInput union (raw UseFormReturn<T> support added in a recent main commit) is preserved. Raw forms have no registry, so they don't participate in DOM-order sorting and gracefully fall back to RHF's setFocus for their own first error.

Test plan

  • New integration test composeSubmitHandler.focus.test.tsx covers both paths:
    • Two SDK hooks composed under SDKFormProvider with interleaved fields → focus lands on the visually first invalid field.
    • Same scenario using formHookResult prop (no SDKFormProvider) → still focuses the visually first invalid field.
  • New unit tests in composeSubmitHandler.test.ts cover DOM-order sorting, ties broken by left, fallback when no form publishes a registry, fallback when registry has no entries for the error fields.
  • All 2,466 existing tests pass.
  • TypeScript: clean.
  • Lint: clean (no new warnings).

Made with Cursor

When composeSubmitHandler validates multiple forms and any are invalid, focus
now lands on the field that's visually first in the DOM rather than the first
form's first error in array order. This fixes the cross-form gap that surfaced
when partners interleave fields from multiple form hooks on the same page.

Implementation introduces a per-form FieldElementRegistry populated by useField
on mount/unmount, read by composeSubmitHandler via _fieldElementRegistry to
sort invalid fields by getBoundingClientRect() and focus the winner directly
(bypassing RHF setFocus, which doesn't reliably resolve refs through
react-aria-components).

The registry is published via context through two complementary paths covering
both field-connection modes documented in docs/hooks/hooks.md:
- SDKFormProvider wraps with FieldElementRegistryProvider for the
  provider-based mode.
- HookField components self-wrap via withFieldElementRegistry for the
  formHookResult-prop mode, where SDKFormProvider is absent.

Each SDK form hook now constructs hookFormInternals via the new
useHookFormInternals helper, which attaches the registry instance.

Co-authored-by: Cursor <cursoragent@cursor.com>
@gusto-fresh-eyes
Copy link
Copy Markdown

gusto-fresh-eyes Bot commented May 5, 2026

No issues found

Generated by Fresh Eyes Reviewer | Share feedback in #ai-code-reviews

Co-authored-by: Cursor <cursoragent@cursor.com>
import { useController, useFormContext } from 'react-hook-form'
import React, { useMemo, type Ref } from 'react'
import React, { useCallback, useMemo, type Ref } from 'react'
import { useFieldElementRegistryContext } from '@/partner-hook-utils/form/fieldElementRegistry'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Major | module-boundaries

Reverse dependency: Common/Fields/hooks/useField.ts imports from @/partner-hook-utils/form/fieldElementRegistry. The established pattern is one-directional: partner-hook-utils depends on Common, never the reverse. This creates a bidirectional coupling between the two modules (partner-hook-utils/form/fields/*HookFieldCommon (via TextInputField etc.) → partner-hook-utils). The fieldElementRegistry context/hook could live in Common/Fields/hooks/ or a shared lower-level location instead, since it's consumed at the useField level and has no dependencies on partner-hook-utils internals.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 7ff313b. Moved fieldElementRegistry.ts and FieldElementRegistryProvider.tsx to src/components/Common/Fields/hooks/ so useField consumes them via a local relative import. The partner-facing re-exports from partner-hook-utils/form are preserved (just changed source path), so the public API is unchanged.

Comment on lines +68 to +73
const fieldElementRegistry = useFieldElementRegistryContext()
const registryRef = useCallback(
(element: HTMLElement | null) => {
if (!fieldElementRegistry) return
fieldElementRegistry.register(name, element)
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor | test-coverage

The new registryRef callback in useField is not tested in useField.test.tsx. While it's exercised indirectly via the composeSubmitHandler.focus.test.tsx integration test, useField's own test file has no coverage for the registry registration/unregistration lifecycle.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added direct coverage in 7ff313b. useField.test.tsx now has a field element registry describe block covering: registration on mount, unregistration on unmount, cleanup-then-register when the field name changes, and no-op behavior when no registry is published in context.

Comment on lines +19 to +21
elements.set(name, element)
} else {
elements.delete(name)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor | test-coverage

The cleanup/unmount path (register called with null to delete an entry) has no direct test. The integration tests verify registration works but don't explicitly exercise field unregistration when a component unmounts.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added direct coverage in 7ff313b. New fieldElementRegistry.test.tsx exercises the cleanup path explicitly: register(name, null) deleting an existing entry, no-op when the entry was never registered, and isolation (deleting one entry doesn't affect siblings). Also covers overwrite-via-re-register and stable instance across renders.

Addresses PR review: useField was reverse-importing from `partner-hook-utils`,
flipping the established module-boundary direction. Moves the registry interface,
hook, context, and Provider to live next to `useField` itself. Public re-exports
from `partner-hook-utils/form` are preserved so the partner API is unchanged.

Also adds direct unit-test coverage for:
- `useField`'s registryRef registration / unregistration / name-change /
  no-context-no-op lifecycle.
- `fieldElementRegistry`'s `register(name, null)` cleanup path, multi-entry
  isolation, overwrite, and stable-instance behavior of `useFieldElementRegistry`.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment on lines +55 to +57
const fieldElementRegistry = formHookResult?.form.hookFormInternals._fieldElementRegistry

return { metadata, control, errorMessage, fieldElementRegistry }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Info | test-coverage

The new fieldElementRegistry return path in useHookFieldResolution has no dedicated test. While it's tested indirectly through the integration tests (InterleavedHookFieldsScreen), a direct unit test for useHookFieldResolution verifying that fieldElementRegistry is passed through from formHookResult would isolate this behavior.

@serikjensen serikjensen enabled auto-merge (squash) May 7, 2026 14:58
@serikjensen serikjensen merged commit 61df209 into main May 7, 2026
17 checks passed
@serikjensen serikjensen deleted the feat/cross-form-dom-order-focus branch May 7, 2026 15:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants